refactor: eliminate N+1 queries in dashboard endpoints using bulk ser…#37
Conversation
…vice methods
- Replace per-membership prisma.dailyResult loops in getDashboard and
getTodayStatus with two bulk queries using memberId: { in: memberIds }
- Add getBulkTodayResults() and getBulkMemberDailyResults() service methods
that fetch all results in one query and return maps keyed by memberId
- Add normaliseToMidnight() helper to centralise date normalisation logic
- Group results in-memory via reduce for O(1) controller-level lookup
- Add normaliseToMidnight() helper to centralise date normalisation logic
- Fixes 30+ DB queries per request (15 challenges) down to 3 queries for
getDashboard and 2 for getTodayStatus
Closes gdg-charusat#21
|
✅ PR Validation PassedHey @Aryan-B-Parikh! Your PR looks good. Here is what we found:
A maintainer will review your PR within 24–48 hours. Stay responsive to feedback!
|
There was a problem hiding this comment.
Pull request overview
This PR refactors the getDashboard and getTodayStatus endpoints in dashboard.controller.js to eliminate N+1 query problems by introducing bulk query functions in evaluation.service.js. Previously, each endpoint made 2 separate database queries per active membership (one for today's result, one for recent results), resulting in 30+ queries for a user with 15 challenges. The refactoring consolidates these into 2-3 bulk queries total, regardless of membership count.
Changes:
- Added three new functions to
evaluation.service.js:normaliseToMidnight()helper,getBulkTodayResults(), andgetBulkMemberDailyResults()for bulk data fetching with in-memory grouping - Refactored
getDashboardandgetTodayStatuscontrollers to use bulk query functions instead of per-membership queries, with early-return guards for empty memberships - Reduced database queries from 31 to 3 for
getDashboardand 16 to 2 forgetTodayStatus(with 15 challenges)
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/services/evaluation.service.js | Adds normaliseToMidnight() helper and two bulk query functions (getBulkTodayResults, getBulkMemberDailyResults) that fetch and group daily results for multiple members in single queries |
| src/controllers/dashboard.controller.js | Refactors getDashboard and getTodayStatus to eliminate N+1 queries by delegating to new bulk query functions and performing data mapping in memory |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
The getChallengeLeaderboard endpoint still has an N+1 query pattern similar to what was fixed in getDashboard and getTodayStatus. For each member, it queries prisma.dailyResult.findMany individually. Consider refactoring this to use a bulk query approach similar to getBulkMemberDailyResults to improve performance when there are many members in a challenge.
| const getTodayStatus = async (memberId) => { | ||
| const today = new Date(); | ||
| today.setHours(0, 0, 0, 0); | ||
|
|
||
| return await prisma.dailyResult.findUnique({ | ||
| where: { | ||
| challengeId_memberId_date: { | ||
| challengeId: ( | ||
| await prisma.challengeMember.findUnique({ where: { id: memberId } }) | ||
| ).challengeId, | ||
| memberId, | ||
| date: today, | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
The getTodayStatus function should use the new normaliseToMidnight helper for consistency with the new bulk functions and the rest of the codebase. Currently, it still uses the inline setHours pattern, which is what the new helper was designed to replace.
| @@ -1 +1 @@ | |||
| const { prisma } = require("../config/prisma"); | |||
There was a problem hiding this comment.
- Fix N+1 in getChallengeLeaderboard: replace per-member prisma loop with getBulkAllMemberResults() which fetches all dailyResult rows for all members in one query (selecting only memberId + completed) and aggregates totalDays/completedDays in-memory - Use normaliseToMidnight() in getTodayStatus service function instead of inline setHours pattern, consistent with new helper convention - Export getBulkAllMemberResults from evaluation.service.js
🎉 PR Merged — Points Awarded!Congratulations @Aryan-B-Parikh! Your contribution has been merged.
The central leaderboard has been updated. Keep contributing!
|
Team Number : Team 146
Linked Issue
Closes #34
Problem
getDashboardandgetTodayStatusindashboard.controller.jssuffered from a severe N+1 query problem. For every active membership, two separate database queries were fired inside aPromise.all(memberships.map(...))loop:prisma.dailyResult.findUnique— one per membership for today's resultevaluationService.getMemberDailyResults→ anotherprisma.dailyResult.findManyper membership for recent resultsFor a user in 15 challenges, this produced 30+ database queries per single API request, rapidly exhausting the PostgreSQL connection pool under load.
Solution
Refactored both endpoints to query the database in bulk and group data in memory, following standard ORM performance best practices.
Changes —
src/services/evaluation.service.jsnormaliseToMidnight()— a shared pure helper that normalises anyDateto local midnight, eliminating repeated inline date mutation across the codebase.getBulkTodayResults(memberIds)— fetches today'sdailyResultrows for all members in one query, returns a{ [memberId]: result }map for O(1) lookup.getBulkMemberDailyResults(memberIds, daysBack)— fetches the last N calendar days of results for all members in one query, returns a{ [memberId]: result[] }map. Uses a calendar window (nottake: N) so missed days are correctly represented as absent entries in the activity strip.Changes —
src/controllers/dashboard.controller.jsgetDashboard: removed all inline Prisma queries, date normalization, andreducegrouping. Delegates entirely togetBulkTodayResults+getBulkMemberDailyResultsviaPromise.all. Response mapping is now purely in-memory.getTodayStatus: same treatment — removed the per-membershipfindUniqueloop, replaced with a single call togetBulkTodayResults.Query Reduction
GET /api/dashboard(15 challenges)GET /api/dashboard/today(15 challenges)Testing
── normaliseToMidnight ──────────────────────────────────────
✅ hours set to 0
✅ minutes set to 0
✅ seconds set to 0
✅ ms set to 0
✅ returns a new Date (no mutation)
✅ original date is not mutated
── getBulkTodayResults — in-memory grouping ─────────────────
✅ member A has a today result
✅ member A completed today
✅ member B has a today result
✅ member B did not complete today
✅ member C has no today result (absent = missed day)
✅ empty rows → empty map
── getBulkMemberDailyResults — in-memory grouping ───────────
✅ member A has 3 recent entries
✅ member B has 1 recent entry
✅ member C absent (no results in window)
✅ member A results are ordered newest-first
✅ empty rows → empty map
── getBulkAllMemberResults — leaderboard aggregation ────────
✅ member A totalDays = 3
✅ member A completedDays = 2
✅ member B totalDays = 3
✅ member B completedDays = 1
✅ member C absent → no entry
✅ member A completionRate = 66.67 (expected 66.67)
✅ empty rows → empty map
── getDashboard — controller response shaping ───────────────
✅ returns entry for every membership
✅ member A todayStatus populated
✅ member A todayStatus.completed correct
✅ member A has 3 recent results
✅ member B todayStatus populated
✅ member B todayStatus.completed correct
✅ member C todayStatus is null (no result)
✅ member C recentResults is empty array
── getTodayStatus — controller response shaping ─────────────
✅ returns entry for every membership
✅ member A challengeId correct
✅ member A status populated
✅ member A status.completed correct
✅ member A submissionsCount correct
✅ member B status.completed correct
✅ member C status is null
── getChallengeLeaderboard — response shaping ───────────────
✅ returns entry for every member
✅ alice is first (order preserved from DB sort)
✅ alice totalDays = 3
✅ alice completedDays = 2
✅ alice completionRate = 66.67
✅ bob completedDays = 1
✅ bob completionRate = 33.33
✅ carol totalDays = 0 (no results)
✅ carol completionRate = 0.00
✅ carol leetcodeUsername null handled safely
── Edge cases ───────────────────────────────────────────────
✅ empty memberships array triggers early return
✅ groupTodayResults([]) → {}
✅ groupRecentResults([]) → {}
✅ aggregateAllResults([]) → {}
✅ last row wins for duplicate memberId (consistent with DB unique constraint)
────────────────────────────────────────────────────────────
Results: 54 passed, 0 failed
ALL TESTS PASSED ✅